/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.tools.idea.gradle.project.sync.issues

import com.android.tools.analytics.UsageTracker
import com.android.tools.idea.gradle.project.build.output.BuildOutputErrorsListener
import com.android.tools.idea.gradle.project.build.output.tomlParser.TomlErrorParser.Companion.isTomlError
import com.android.tools.idea.gradle.project.sync.GradleSyncStateHolder
import com.google.wireless.android.sdk.stats.AndroidStudioEvent
import com.google.wireless.android.sdk.stats.AndroidStudioEvent.GradleSyncFailure
import com.google.wireless.android.sdk.stats.BuildOutputWindowStats
import com.google.wireless.android.sdk.stats.GradleFailureDetails
import com.intellij.build.SyncViewManager
import com.intellij.build.events.FinishBuildEvent
import com.intellij.build.output.BuildOutputParser
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.externalSystem.issue.BuildIssueException
import com.intellij.openapi.externalSystem.model.task.ExternalSystemTaskId
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import org.jetbrains.annotations.SystemIndependent
import org.jetbrains.plugins.gradle.util.GradleBundle
import java.util.concurrent.ConcurrentHashMap

private val LOG = Logger.getInstance(SyncFailureUsageReporter::class.java)

/**
 * This service is responsible for collecting sync failure information to be reported to metrics.
 * Failure means that an exception was thrown from Gradle during sync and if this exception is recognised during exception handling
 * process the corresponding value will be collected in this service.
 * In the end, in `onSyncFailure` listener call, collected value is
 * reported to the metrics.
 * This service waits for [FinishBuildEvent] to be sent to [SyncViewManager], it subscribes to the [SyncViewManager] at sync start
 * for this. Waiting for this event guarantees that both IssueChecker error handling flow and [BuildOutputParser] flow finished processing,
 * so we have all error-related data to report.
 */
@Service(Service.Level.APP)
class SyncFailureUsageReporter {
  private val collectedFailureTypesByProjectPath = ConcurrentHashMap<String, GradleSyncFailure>()
  private val collectedFailureDetailsByProjectPath = ConcurrentHashMap<String, GradleFailureDetails>()
  private val collectedFailureDetailsByBuildId = ConcurrentHashMap<Any, AndroidStudioEvent.Builder>()

  companion object {
    @JvmStatic
    fun getInstance(): SyncFailureUsageReporter = ApplicationManager.getApplication().getService(SyncFailureUsageReporter::class.java)
  }

  fun onSyncStart(externalSystemTaskId: ExternalSystemTaskId?, project: Project, rootProjectPath: @SystemIndependent String) {
    if (externalSystemTaskId == null) return
    collectedFailureTypesByProjectPath.remove(rootProjectPath)
    collectedFailureDetailsByProjectPath.remove(rootProjectPath)

    val disposable = Disposer.newDisposable("syncViewListenerDisposable")
    Disposer.register(project, disposable)
    val errorsListener = BuildOutputErrorsListener(externalSystemTaskId, disposable) { buildErrorMessages ->
      collectedFailureDetailsByBuildId[externalSystemTaskId]?.let {
        // Attach stats generated by BuildOutputParsers
        val buildOutputWindowStats = BuildOutputWindowStats.newBuilder().addAllBuildErrorMessages(buildErrorMessages).build()
        UsageTracker.log(it.setBuildOutputWindowStats(buildOutputWindowStats))
      }
    }
    Disposer.register(disposable) {
      collectedFailureDetailsByBuildId.remove(externalSystemTaskId)
    }
    project.getService(SyncViewManager::class.java).addListener(errorsListener, disposable)
  }

  fun collectFailure(rootProjectPath: @SystemIndependent String, failure: GradleSyncFailure) {
    val previousValue = collectedFailureTypesByProjectPath.put(rootProjectPath, failure)
    if (previousValue != null) {
      LOG.warn("Multiple sync failures reported. Discarding: $previousValue")
    }
  }

  fun collectProcessedError(externalSystemTaskId: ExternalSystemTaskId?, project: Project, rootProjectPath: @SystemIndependent String, processedError: Throwable?) {
    if (externalSystemTaskId == null) return
    // If nothing was collected by the issue checkers try to derive a bit more details from the processed error.
    // e.g. if it has a BuildIssue attached then something just did not report the recognized failure.
    val failureType = collectedFailureTypesByProjectPath.remove(rootProjectPath) ?: deriveSyncFailureFromProcessedError(processedError)
    val failureErrorDetails = collectedFailureDetailsByProjectPath.remove(rootProjectPath)
                              ?: extractGradleFailureDetails(processedError)
                              ?: GradleFailureDetails.newBuilder().build()
    // At this point we start waiting for finish event in the listener and loosing guarantee that no other sync starts before that.
    // Create half-prepared event from what we have now and put it to the map by build id.
    val syncStateHolder = GradleSyncStateHolder.getInstance(project)
    collectedFailureDetailsByBuildId[externalSystemTaskId] = syncStateHolder
      .generateSyncEvent(AndroidStudioEvent.EventKind.GRADLE_SYNC_FAILURE_DETAILS, rootProjectPath)
      .setGradleSyncFailure(failureType)
      .setGradleFailureDetails(failureErrorDetails)
  }

  private fun deriveSyncFailureFromProcessedError(error: Throwable?) = if (error is BuildIssueException) {
    when {
      error.buildIssue.javaClass.packageName.startsWith("com.android.tools.") -> GradleSyncFailure.ANDROID_BUILD_ISSUE_CREATED_UNKNOWN_FAILURE
      error.buildIssue.title == GradleBundle.message("gradle.build.issue.gradle.unsupported.title") -> GradleSyncFailure.UNSUPPORTED_GRADLE_VERSION
      else -> GradleSyncFailure.BUILD_ISSUE_CREATED_UNKNOWN_FAILURE
    }
  }
  else {
    when {
      error?.message?.startsWith("Could not find method ") == true -> GradleSyncFailure.DSL_METHOD_NOT_FOUND
      error?.message?.startsWith("Could not get unknown property ") == true -> GradleSyncFailure.DSL_METHOD_NOT_FOUND
      error?.message?.startsWith("Could not set unknown property ") == true -> GradleSyncFailure.DSL_METHOD_NOT_FOUND
      error?.message?.startsWith("Script compilation error:") == true -> GradleSyncFailure.KTS_COMPILATION_ERROR
      error?.message?.startsWith("Compilation failed; see the compiler ") == true -> GradleSyncFailure.JAVA_COMPILATION_ERROR
      error?.message?.startsWith("Cannot cast object ") == true -> GradleSyncFailure.CANNOT_BE_CAST_TO // Cast exception in groovy code
      error?.isTomlError() == true -> GradleSyncFailure.INVALID_TOML_DEFINITION
      error?.cause?.toString()?.startsWith("org.codehaus.groovy.control.MultipleCompilationErrorsException:") == true ->
        GradleSyncFailure.GROOVY_COMPILATION_ERROR
      error?.cause?.toString()?.startsWith("org.gradle.api.plugins.UnknownPluginException: Plugin [id: 'com.android.") == true ->
        GradleSyncFailure.UNKNOWN_PLUGIN_COM_ANDROID
      error?.cause?.toString()?.startsWith("org.gradle.api.plugins.UnknownPluginException: Plugin [id: '") == true ->
        GradleSyncFailure.UNKNOWN_PLUGIN_OTHER
      error?.cause?.toString()?.startsWith("org.gradle.internal.resolve.ModuleVersionNotFoundException:") == true ->
        GradleSyncFailure.MISSING_DEPENDENCY_OTHER
      else -> GradleSyncFailure.UNKNOWN_GRADLE_FAILURE
    }
  }

  fun collectUnprocessedGradleError(rootProjectPath: @SystemIndependent String, gradleError: Throwable?) {
    extractGradleFailureDetails(gradleError)?.let { gradleFailureDetails ->
      collectedFailureDetailsByProjectPath.put(rootProjectPath, gradleFailureDetails)
    }
  }

  private fun extractGradleFailureDetails(gradleError: Throwable?): GradleFailureDetails? {
    if (gradleError == null) return null
    return GradleExceptionAnalyticsSupport().extractFailureDetails(gradleError).toAnalyticsMessage()
  }
}
